跳到主要内容

复现一个简单的消息钩子

顾名思义,DLL注入是将某些DLL(动态链接库)强行加载到某个程序的进程中,从而完成某些特定的功能,。DLL注入与普通的DLL 加载的区别在于,加载的目标是其自身或者其他特定程序,而注入的目标是强制在某个进程中插入自定义的DLL。

在DLL注入的过程中,会频繁地接触到钩子这个概念,也就是Hook这个操作。

钩子的主要作用就是将消息和进程钩取过来,对于被钩取到的消息和进程,我们可以自己写程序对其进行一些修改或者查看,这样就完成了对于程序原本功能的修改。

前言:VS2022 工程设置

Visual Studio 2022 可以在一个解决方案中同时包含多个项目,这样就可以在一个环境中同时编译和调试 KeyHook DLL 项目和 main 项目。下面是具体步骤:

  1. 创建解决方案并添加项目:

    1. 打开 Visual Studio 2022,选择 File -> New -> Project
    2. 创建一个新的 Blank Solution,为其命名并选择保存位置。
  2. 添加 KeyHook 项目:

    1. 在解决方案资源管理器中,右键点击解决方案,选择 Add -> New Project
    2. 选择 Dynamic-link Library (DLL) 项目模板,为项目命名(例如 KeyHook),并点击 Create
    3. KeyHook 项目中添加 KeyHook.cpp 文件。
  3. 添加 main 项目:

    1. 右键点击解决方案,再次选择 Add -> New Project
    2. 选择 Console Application 项目模板,为项目命名(例如 MainApp),并点击 Create
    3. MainApp 项目中添加 main.cpp 文件。
  4. 设置项目依赖:

    1. 在解决方案资源管理器中,右键点击 MainApp 项目,选择 Project Dependencies
    2. 在弹出的对话框中,选择 Depends on 选项卡,并勾选 KeyHook 项目。这样可以确保在编译 MainApp 项目之前先编译 KeyHook 项目。
  5. 设置调试参数:

    1. 确保 MainApp 项目是启动项目。右键点击 MainApp 项目,选择 Set as Startup Project
    2. MainApp 项目属性中,导航到 Debugging 标签。
    3. Environment 字段中,添加以下环境变量,使 main.cpp 能够找到生成的 DLL 文件:
      PATH=$(SolutionDir)KeyHook\$(OutDir);
  6. 设置编译输出路径:

    1. 确保 KeyHookMainApp 项目都使用相同的编译输出路径,这样 MainApp 项目可以找到 KeyHook.dll
    2. 右键点击 KeyHook 项目,选择 Properties
    3. Configuration Properties -> General 下,将 Output Directory 设置为相对于解决方案目录,例如:
      $(SolutionDir)Bin\$(Configuration)\
    4. MainApp 项目执行相同操作,将其 Output Directory 设置为相同路径。
  7. 编译并调试:

    1. 右键点击解决方案,选择 Build Solution,确保所有项目成功编译。
    2. 设置断点(如在 HookStartHookStop 及其他关键函数中)。
    3. 按下 F5 键或点击 Start Debugging 按钮,开始调试。

通过上述步骤,就可以在一个解决方案中同时包含和调试 KeyHookMainApp 项目。这使得调试和测试更方便,因为你可以在一个环境中管理所有相关代码。

这个 $(SolutionDir) 是 Visual Studio 中的一个宏,用于表示解决方案文件 (.sln) 所在的目录。

设置输出路径

假设你想让所有项目的输出文件都放在一个公共的 Bin 目录中,可以这样设置:

  1. 设置 KeyHook 项目的输出路径:

    1. 右键点击 KeyHook 项目,选择 Properties
    2. Configuration Properties -> General 下,将 Output Directory 设置为:
      $(SolutionDir)Bin\$(Configuration)\
  2. 设置 MainApp 项目的输出路径:

    1. 右键点击 MainApp 项目,选择 Properties
    2. Configuration Properties -> General 下,将 Output Directory 设置为:
      $(SolutionDir)Bin\$(Configuration)\

这样,无论你在哪个机器上打开这个解决方案,输出文件都会被放到 MySolution\Bin\DebugMySolution\Bin\Release 目录中,取决于你选择的编译配置(Debug 或 Release)。

设置调试环境变量

为了确保 MainApp 能够找到 KeyHook.dll,可以在 MainApp 项目的调试环境变量中添加路径:

  1. 右键点击 MainApp 项目,选择 Properties
  2. 导航到 Configuration Properties -> Debugging 标签。
  3. Environment 字段中,添加以下环境变量:
    PATH=$(SolutionDir)Bin\$(Configuration);

这样,MainApp 在调试时会在指定目录中寻找 KeyHook.dll,确保加载成功。

消息钩子

本文要实现的功能主要依托于 Windows 中的消息钩子。Windows 为用于提供了相当完备的GUI(图形化操作界面),而用户通过鼠标,键盘,光驱等外设与系统进行交互。在 Windows 中,每一次鼠标点击和键盘输入都可以被叫做是一个事件,Windows 就是基于这些事件驱动的系统。

当按下键盘上的某个键时,这个键被按下的消息传递到 Windows 的事件队列中等待处理,这时的键盘事件还没有进入到应用程序加以处理,而在系统消息队列和应用之间就是架设消息钩子的空间,在这里可以通过钩子钩取即将被传入应用的事件并加以处理,大概流程如下图:

钩取键盘消息

下面就对钩取键盘消息进行实际操作,开始之前先要准备一个进程查看器:Process Explorer,这个进程查看器功能相当强大,可以看到进程都加载了哪些 DLL,以及某个 DLL 被哪些进程加载过。本次操作是基于 notepad.exe(也就是记事本软件)的DLL注入

KeyHook.cpp

首先来看一下完成主要功能的动态链接库,也就是后面将注入 notepad.exe 的 DLL 文件。

#include "stdio.h"
#include "windows.h"

#define PROCESS_NAME "notepad.exe" // 定义需要检测的进程名称

HINSTANCE g_hInstance = NULL; // 全局实例句柄
HHOOK g_Hook = NULL; // 全局钩子句柄
HWND g_hWnd = NULL; // 全局窗口句柄

// DLL入口函数
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH: // 当DLL被加载到进程地址空间时
g_hInstance = hinstDLL; // 保存实例句柄
break;
case DLL_PROCESS_DETACH: // 当DLL从进程地址空间卸载时
break;
default:
break;
}
return TRUE; // 返回TRUE表示成功
}

// 键盘钩子过程函数
LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
{
char szPath[MAX_PATH] = { 0, }; // 保存进程路径
char* p = NULL; // 用于保存进程名部分
if (nCode >= 0) // 如果钩子信息有效
{
if (!(lParam & 0x80000000)) // 如果是键盘按下事件
{
GetModuleFileNameA(NULL, szPath, MAX_PATH); // 获取当前进程路径
p = strrchr(szPath, '\\'); // 找到路径中的最后一个反斜杠

if (!_stricmp(p + 1, PROCESS_NAME)) // 如果进程名匹配
{
return 1; // 阻止键盘事件传递
}
}
}
return CallNextHookEx(g_Hook, nCode, wParam, lParam); // 调用下一个钩子
}

#ifdef __cplusplus
extern "C" {
#endif // __cplusplus

// 导出函数,用于启动钩子
__declspec(dllexport) void HookStart()
{
g_Hook = SetWindowsHookEx(WH_KEYBOARD, KeyboardProc, g_hInstance, 0); // 设置键盘钩子
}

// 导出函数,用于停止钩子
__declspec(dllexport) void HookStop()
{
if (g_Hook)
{
UnhookWindowsHookEx(g_Hook); // 卸载键盘钩子
g_Hook = NULL; // 清空钩子句柄
}
}

#ifdef __cplusplus
}
#endif // __cplusplus

其实程序的逻辑非常简单,大概如下:

  1. 创建 DLL 入口点,当遇到 DLL_PROCESS_ATTACH 事件时获取 DLL 进程的实例化句柄
  2. 构造一个 KeyboardProc 回调函数,根据获取到的消息进行操作
  3. 使用前面获得的实例化句柄和回调函数创建钩子进程
  4. 程序结束时卸载钩子进程

下面来具体分析每个部分

DLL 入口点函数

在自己编写 DLL 的过程中,要注意程序的入口点函数是一个固定的模式,这个模式可以在 MSDN(Windows 的官方帮助文档)上查到,如下:

BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // handle to DLL module
DWORD fdwReason, // reason for calling function
LPVOID lpvReserved ) // reserved
{
// Perform actions based on the reason for calling.
switch( fdwReason )
{
case DLL_PROCESS_ATTACH:
// Initialize once for each new process.
// Return FALSE to fail DLL load.
break;

case DLL_THREAD_ATTACH:
// Do thread-specific initialization.
break;

case DLL_THREAD_DETACH:
// Do thread-specific cleanup.
break;

case DLL_PROCESS_DETACH:

if (lpvReserved != nullptr)
{
break; // do not do cleanup if process termination scenario
}

// Perform any necessary cleanup.
break;
}
return TRUE; // Successful DLL_PROCESS_ATTACH.
}

在 DllMain 函数中有三个参数,分别是:

  • hinstDLL:DLL 模块的实例化句柄,也就是 DLL 加载时的基址
  • fdwReason:标识调用 DLL 入口点函数的原因,有 0、1、2、3 这个四个值,对应四种不同情况。
  • lpvReserved:用于指示DLL是静态加载还是动态加载

本次复现主要接触到的是 fdwReason 值为 0 和 1 时的两种情况(2 和 3的情况在后面学习线程注入时会接触到):

  • fdwReason 值为 1:对应 DLL_PROCESS_ATTACH,进程使用 LoadLibrary 加载 DLL 时(也就是 DLL 模块被加载入虚拟地址空间时),系统会接收到这个消息
  • fdwReason 值为 0:对应 DLL_PROCESS_DETACH,当进程使用 FreeLibrary 卸载掉 DLL 时(也就是 DLL 模块被从虚拟空间中卸载时),系统会受到这个消息

在函数内部是一个 Switch 选择结构,根据 fdwReason(也就是 DLL 的加载情况)执行相应操作,本次的复现中采用的是最简单的一种:也就是当 DLL 模块加载成功时将 DLL 的实例化句柄赋值给全局变量 hInstance。这部分的代码及注释如下:

HINSTANCE g_hInstance = NULL; //实例化句柄类型,简单理解为内存分配了的资源ID
HHOOK g_Hook = NULL; //钩子的句柄
HWND g_hWnd = NULL; //窗口句柄

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) //DLL入口点函数
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH: //当系统事件为DLL被初次映射到内存中时
g_hInstance = hinstDLL; //前面的实例化句柄被赋值为DLLMain的模块句柄
break;
case DLL_PROCESS_DETACH:
break;
default:
break;
}
return TRUE;
}

KeyboardProc 回调函数

KeyboardProc,也就是键盘消息进程的函数,它是一个回调函数,它作为参数在 SetWindowsHookEx 这个 API 中使用,这个回调函数也有一个比较固定的模板,在 MSDN 上可以查到:

LRESULT CALLBACK KeyboardProc(
_In_ int code,
_In_ WPARAM wParam,
_In_ LPARAM lParam
);

有三个参数:

  1. code:这个参数的值决定如何处理消息,分为 0、3 和小于 0 两种情况

    • 小于 0:必须调用 CallNextHookEx 函数传递消息(也就是传递给钩链中的下一个钩子),且不能做过多操作
    • 0:表示参数 wParam 和 lParam 包含关于虚拟键值相关信息(正常输入的情况下就是这个值)
    • 3:在值为 0 的基础上,表示这个消息被某个进程用 PeekMessage 查看过
  2. wParam:按下键盘的按键后生成的虚拟键值,用于判断按下了哪个键

  3. lParam:这是一个组合值,它的每个不同的 bit 位代表不同的情况,具体可以在官方文档中查看,本次复现主要关注它的第 31 位 bit 位:

31 转换状态:如果按下键,则值为 0; 如果正在释放键,则值为 1。

这一部分的代码和注释如下:

LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
// nCode: 确定如何处理消息的代码
// wParam:获取键盘输入的虚拟键值
// lParam:扩展键值,比较复杂,这里不多解释
{
char szPath[MAX_PATH] = { 0, };
//TCHAR szPath[MAX_PATH] = { 0, };
char* p = NULL;
if (nCode >= 0) //nCode 大于 0 时表明接收到键盘消息是正常的
{
if (!(lParam & 0x80000000)) //lParam 的第 31 位bit位的值代表按键是按下还是释放,0->press 1->release
{
GetModuleFileNameA(NULL, szPath, MAX_PATH);
p = strrchr(szPath, '\\'); //如果使用TCHAR的字符数组要把项目使用的字符集改为多字节字符集
//strrchr函数:在一个字符串中查找目标字符串末次出现的位置
if (!_stricmp(p + 1, PROCESS_NAME)) //判断当前进程是否为 notepad
//stricmp函数:比较两个字符串,比较过程不区分大小写
{
return 1;
}
}
}
return CallNextHookEx(g_Hook, nCode, wParam, lParam); //如果当前进程是 notepad 就将消息传递给下一个程序
}

导出函数 HookStart() 与 HookStop()

这两个函数就是后面将被导出到主程序中使用的开启 Hook 和 卸载 Hook 的函数,本次的复现中写的很简单,就是调用了一个建立钩子进程的 API,但是还有些地方需要注意

在我们使用 VS 编写 DLL 时,生成的源文件后缀是 .cpp,也就是 C++ 文件,但是有些函数是只能在 C 语言下解析,所以我们使用 C++ 中解析 C 语言的一个模式:

#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
.
.
.
#ifdef __cplusplus
}
#endif // __cplusplus

当我们需要在 DLL 中导出函数时,要用一个前缀标识这个函数为导出函数,如下:

__declspec(dllexport)

这个前缀标识后面的函数为 DLL 的导出函数,默认的调用约定是 _srdcall

在 HookStart 创建钩子进程时会调用一个 API:SetWindowsHookEx,它在 MSDN 中可以查询到:

HHOOK SetWindowsHookExA(
[in] int idHook,
[in] HOOKPROC lpfn,
[in] HINSTANCE hmod,
[in] DWORD dwThreadId
);

拥有四个参数:

  • idHook:表示需要安装的挂钩进程的类型,有很多,具体可以在MSDN上查,这次主要使用 WH_KEYBOARD 这个类型(安装监视击键消息的挂钩过程)
  • lpfn:指向钩子过程的指针
  • hmod:关于钩子进程的实例化句柄
  • dwThreadId:指向一个线程标识符,如果当前的钩子进程与现存的线程相关,那么它的值就是0

这一部分的代码及注释如下:

#ifdef __cplusplus
extern "C" { //后面的导出函数将使用C语言进行解析
#endif // __cplusplus
__declspec(dllexport) void HookStart() //创建钩子进程
{
// 创建钩子进程
g_Hook = SetWindowsHookEx(WH_KEYBOARD, KeyboardProc, g_hInstance, 0);
}
__declspec(dllexport) void HookStop() //卸载钩子进程
{
if (g_Hook)
{
UnhookWindowsHookEx(g_Hook); //卸载钩子进程
g_Hook = NULL;
}
}
#ifdef __cplusplus
}
#endif // __cplusplus

main 模块载入 DLL

还是先看总的源码:

#include"stdio.h"
#include"Windows.h"
#include"conio.h"

#define DLL_NAME "KeyHook.dll"
#define HOOKSTART "HookStart"
#define HOOKSTOP "HookStop"

typedef void(*FN_HOOKSTART)();
typedef void(*FN_HOOKSTOP)();

void main()
{
HMODULE hDll = NULL;
FN_HOOKSTART HookStart = NULL;
FN_HOOKSTOP HookStop = NULL;

hDll = LoadLibraryA(DLL_NAME);

HookStart = (FN_HOOKSTART)GetProcAddress(hDll, HOOKSTART);
HookStop = (FN_HOOKSTOP)GetProcAddress(hDll, HOOKSTOP);

HookStart();

printf("press 'q' to quit this hook procdure");
while (_getch() != 'q');

HookStop();

FreeLibrary(hDll);
}

程序流程也比较简单:

  1. 通过 LoadLibraryA 加载前面编写好的 DLL
  2. 通过 GetProcAddress 获取 DLL 中的函数地址后赋给前面定义好的函数指针
  3. 启动钩子进程
  4. 等待程序结束
  5. 卸载钩子进程

LoadLibraryA 加载 DLL

这个操作很简单,就是调用 LoadLibraryA 这个 API 加载 DLL,它在 MSDN 中可以查到为:

HMODULE LoadLibraryA(
[in] LPCSTR lpLibFileName
);

只有一个参数,就是需要载入的模块的名称,这里还要着重讲一下前面的一些操作:

typedef void(*FN_HOOKSTART)();
typedef void(*FN_HOOKSTOP)();

这个 typedef 看起来跟平时用到的 typedef 有点不一样,按照正常的理解,typedef 应该是给一个什么东西 “取别名”,那么这里就应该是给 void 取别名为 *FN_HOOKSTART,但这样用起来就很奇怪。

其实正确的理解与上面说到的相差不是很大。由于后面会使用 GetProcAddress 来获取 DLL 中导出函数的地址,我们要调用就需要一个指向这个的指针。

而要导出的两个函数都是参数为 void,返回值也是 void 的函数,所以这里 typedef 的其实就是一个返回值为 void 参数也是 void 的函数指针

这一部分的代码和注释如下:

#include"stdio.h"
#include"Windows.h"
#include"conio.h"

#define DLL_NAME "KeyHook.dll" //定义需要加载的动态库名称
#define HOOKSTART "HookStart" //定义HookStart的全局名称
#define HOOKSTOP "HookStop" //定义HookStop的全局名称

typedef void(*FN_HOOKSTART)(); //定义一个返回值为void参数也是void的函数指针
typedef void(*FN_HOOKSTOP)(); //原理同上
void main()
{
HMODULE hDll = NULL; //模块载入句柄,用来加载DLL

hDll = LoadLibraryA(DLL_NAME); //加载DLL
}

通过 GetProcAddress获 取 DLL 中的函数地址

在前面的文章中调试程序时经常都会看到 LoadLibrary 和 GetProcAddress 这两个函数的联合使用,它们的功能就是在程序中导入外部 DLL 得函数,这 GetProcAddress 在 MSDN 上查到为:

FARPROC GetProcAddress(
[in] HMODULE hModule,
[in] LPCSTR lpProcName
);

这个 API 有两个参数:

  • hModule:需要查找的目的模块的实例化句柄
  • lpProcName:需要查找的函数的名称

通过这个 API 获取到的函数需要使用前面定义的函数指针强转一下类型才能正常的赋值给指针使用。

这一部分的代码与注释如下:

//获取DLL中HookStart的地址,并赋给前面定义好的函数指针
HookStart = (FN_HOOKSTART)GetProcAddress(hDll, HOOKSTART);
HookStop = (FN_HOOKSTOP)GetProcAddress(hDll, HOOKSTOP); //与上面同理

钩子进程的安装与卸载

这一部分所使用的函数和流程都比较简单,不在过多赘述,直接看代码和注释:

HookStart(); //启动钩子进程

printf("press 'q' to quit this hook procdure");

//_getch 为包含在 conin.h 库中的一个函数,功能与 getchar 差不多,但是没有回显
while (_getch() != 'q');

HookStop(); //卸载钩子进程

FreeLibrary(hDll); //卸载 DLL 模块

唯一需要注意的就是在结束钩子进程后要将 DLL 从进程中卸载,也就是要使用 FreeLibrary。

运行测试

首先打开 Hook 程序:

然后打开 notepad

此时在记事本中是无法输入任何内容的,打开 ProcessExplorer 看一下 DLL 的加载情况:

可以看见 KeyHook.dll 已经被强行注入到 notepad 中了

调试测试

References